<html>
<title>Life</title>

<head>

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <style>
        :root {
            color-scheme: dark;
            --canvas-bg-color: rgba(0, 0, 0, 0); /* transparent */
        }

        body {
            display: flex;
            justify-content: center;
            align-items: center;
        }

        #canvas {
            border: #504e52;
            border-style: dashed;
            border-width: 5;
            outline: none;  /* because of the 'tabindex' focus trick */
            background-color: var(--canvas-bg-color);
        }
    </style>
</head>

<body>
    <!-- 'tabindex' is a trick to make the canvas capture key events -->
    <canvas id="canvas" tabindex="1"></canvas>

    <script src="https://cdn.jsdelivr.net/npm/lil-gui@0.17"></script>

    <script>
        const maxRadius = 200;
        const maxClusters = 20;
        const minClusterSize = 50;
        const predefinedColors = ['green', 'red', 'orange', 'cyan', 'magenta', 'lavender', 'teal'];
        const settings = {
            seed: 91651088029,
            fps: 0,
            atoms: {
                count: 500,  // Per Color
                radius: 1,
            },
            drawings: {  // Drawing options can be expensive on performance
                lines: false,   // draw lines between atoms that arr effecting each other
                circle: false,  // draw atoms as circles
                clusters: false,
                background_color: '#000000', // Background color
            },
            export: {
                // Export a Screenshot image
                image: () => {
                    const imageDataURL = canvas.toDataURL({
                        format: 'png',
                        quality: 1
                    });
                    dataURL_downloader(imageDataURL);
                },
                // Export a video recording
                video: () => {
                    mediaRecorder.state == 'recording' ? mediaRecorder.stop() : mediaRecorder.start();
                },
            },
            explore: false,
            explorePeriod: 100,
            rules: {},
            rulesArray: [],
            radii: {},
            radii2Array: [],
            colors: [],
            numColors: 4,
            time_scale: 1.0,
            viscosity: 0.7,  // speed-dampening (can be >1 !)
            gravity: 0.0,  // pulling downward
            pulseDuration: 10,
            wallRepel: 40,
            reset: () => {
                randomAtoms(settings.atoms.count, true)
            },
            randomRules: () => {
                settings.seed = local_seed   // last used seed is the new starting seed
                startRandom()
            },
            symmetricRules: () => {
                symmetricRules()
                randomAtoms(settings.atoms.count, true)
                updateGUIDisplay()
            },
            gui: null,
        }

        const setupClicks = () => {
            canvas.addEventListener('click',
                (e) => {
                    pulse = settings.pulseDuration;
                    if (e.shiftKey) pulse = -pulse;
                    pulse_x = e.clientX;
                    pulse_y = e.clientY;
                }
            )
        }
        const setupKeys = () => {
            canvas.addEventListener('keydown',
                function (e) {
                    console.log(e.key)
                    switch (e.key) {
                        case 'r':
                          settings.randomRules()
                        break;
                        case 't':
                          settings.drawings.clusters = !settings.drawings.clusters
                        break;
                        case 'o':
                          settings.reset()
                        break;
                        case 's':
                          settings.symmetricRules()
                        break;
                        default:
                          console.log(e.key)
                    }
                })
        }
        const updateGUIDisplay = () => {
            console.log('gui', settings.gui)
            settings.gui.destroy()
            setupGUI()
        }
        Object.defineProperty(String.prototype, 'capitalise', {
            value: function() {
                return this.charAt(0).toUpperCase() + this.slice(1);
            },
            enumerable: false
        })

        // Build GUI
        const setupGUI = () => {
            settings.gui = new lil.GUI()
            // Configs
            const configFolder = settings.gui.addFolder('Config')
            configFolder.add(settings, 'reset').name('Reset')
            configFolder.add(settings, 'randomRules').name('Random Rules')
            configFolder.add(settings, 'symmetricRules').name('Symmetric Rules')
            configFolder.add(settings, 'numColors', 1, 7, 1).name('Number of Colors')
                .listen().onFinishChange(v => {
                    setNumberOfColors();
                    startRandom();
                })
            configFolder.add(settings, 'seed').name('Seed').listen().onFinishChange(v => {
                startRandom();
            })
            configFolder.add(settings, 'fps').name('FPS - (Live)').listen().disable()
            configFolder.add(settings.atoms, 'count', 1, 1000, 1).name('Atoms per-color').listen().onFinishChange(v => {
                randomAtoms(v, true)
            })
            configFolder.add(settings, 'time_scale', 0.1, 5, 0.01).name('Time Scale').listen()
            configFolder.add(settings, 'viscosity', 0.1, 2, 0.1).name('Viscosity').listen()

            configFolder.add(settings, 'gravity', 0., 1., 0.05).name('Gravity').listen()
            configFolder.add(settings, 'pulseDuration', 1, 100, 1).name('Click Pulse Duration').listen()

            configFolder.add(settings, 'wallRepel', 0, 100, 1).name('Wall Repel').listen()
            configFolder.add(settings, 'explore').name('Random Exploration').listen()
            // Drawings
            const drawingsFolder = settings.gui.addFolder('Drawings')
            drawingsFolder.add(settings.atoms, 'radius', 1, 10, 0.5).name('Radius').listen()
            drawingsFolder.add(settings.drawings, 'circle').name('Circle Shape').listen()
            drawingsFolder.add(settings.drawings, 'clusters').name('Track Clusters').listen()
            drawingsFolder.add(settings.drawings, 'lines').name('Draw Lines').listen()
            drawingsFolder.addColor(settings.drawings, 'background_color').name('Background Color').listen()
            // Export
            const exportFolder = settings.gui.addFolder('Export')
            exportFolder.add(settings.export, 'image').name('Image')
            exportFolder.add(settings.export, 'video').name('Video: Start / stop')
            // Colors
            for (const atomColor of settings.colors) {
                const colorFolder =
                    settings.gui.addFolder(`Rules: <font color=\'${atomColor}\'>${atomColor.capitalise()}</font>`)
                for (const ruleColor of settings.colors) {
                    colorFolder.add(settings.rules[atomColor], ruleColor, -1, 1, 0.001)
                         .name(`<-> <font color=\'${ruleColor}\'>${ruleColor.capitalise()}</font>`)
                         .listen().onFinishChange(v => { flattenRules() }
                    )
                }
                colorFolder.add(settings.radii, atomColor, 1, maxRadius, 5).name('Radius')
                    .listen().onFinishChange(v => { flattenRules() }
                )
            }


        }


        // Seedable 'decent' random generator
        var local_seed = settings.seed;
        function mulberry32() {
            let t = local_seed += 0x6D2B79F5;
            t = Math.imul(t ^ t >>> 15, t | 1);
            t ^= t + Math.imul(t ^ t >>> 7, t | 61);
            return ((t ^ t >>> 14) >>> 0) / 4294967296.;
        }

        function loadSeedFromUrl() {
            let hash = window.location.hash;
            if (hash != undefined && hash[0] == '#') {
                let param = Number(hash.substr(1)); // remove the leading '#'
                if (isFinite(param)) {
                    settings.seed = param;
                    console.log("Using seed " + settings.seed);
                }
            }
        }

        function randomRules() {
            if (!isFinite(settings.seed)) settings.seed = 0xcafecafe;
            window.location.hash = "#" + settings.seed;
            document.title = "Life #" + settings.seed;
            local_seed = settings.seed;
            console.log("Seed=" + local_seed);
            for (const i of settings.colors) {
                settings.rules[i] = {};
                for (const j of settings.colors) {
                    settings.rules[i][j] = mulberry32() * 2 - 1;
                }
                settings.radii[i] = 80;
            }
            console.log(JSON.stringify(settings.rules));
            flattenRules()
        }

        function symmetricRules() {
            for (const i of settings.colors) {
                for (const j of settings.colors) {
                    if (j < i) {
                        let v = 0.5 * (settings.rules[i][j] + settings.rules[j][i]);
                        settings.rules[i][j] = settings.rules[j][i] = v;
                    }
                }
            }
            console.log(JSON.stringify(settings.rules));
            flattenRules()
        }

        function flattenRules() {
            settings.rulesArray = []
            settings.radii2Array = []
            for (const c1 of settings.colors) {
                for (const c2 of settings.colors) {
                    settings.rulesArray.push(settings.rules[c1][c2])
                }
                settings.radii2Array.push(settings.radii[c1] * settings.radii[c1])
            }
        }

        function updateCanvasDimensions() {
            canvas.width = window.innerWidth * 0.9;
            canvas.height = window.innerHeight * 0.9;
        }

        // Initiate Random locations for Atoms ( used when atoms created )
        function randomX() {
            return mulberry32() * (canvas.width - 100) + 50;
        };

        function randomY() {
            return mulberry32() * (canvas.height - 100) + 50;
        };

        /* Create an Atom - Use matrices for x4/5 performance improvement
        atom[0] = x
        atom[1] = y
        atom[2] = ax
        atom[3] = ay
        atom[4] = color (index)
        */
        const create = (number, color) => {
            for (let i = 0; i < number; i++) {
                atoms.push([randomX(), randomY(), 0, 0, color])
            }
        };

        function randomAtoms(number_of_atoms_per_color, clear_previous) {
            if (clear_previous) atoms.length = 0;
            for (let c = 0; c < settings.colors.length; c++) {
                create(number_of_atoms_per_color, c)
            }
            clusters.length = 0;
        }

        function startRandom() {
            randomRules();
            randomAtoms(settings.atoms.count, true);
            updateGUIDisplay()
        }

        function setNumberOfColors() {
            settings.colors = [];
            for (let i = 0; i < settings.numColors; ++i) {
                settings.colors.push(predefinedColors[i]);
            }
        }

        // Run Application
        loadSeedFromUrl()

        // Canvas
        const canvas = document.getElementById('canvas');
        const m = canvas.getContext("2d");
        // Draw a square
        const drawSquare = (x, y, color, radius) => {
            m.fillStyle = color;
            m.fillRect(x - radius, y - radius, 2 * radius, 2 * radius);
        }

        // Draw a circle
        function drawCircle(x, y, color, radius, fill = true) {
            m.beginPath();
            m.arc(x, y, radius, 0 * Math.PI, 2 * Math.PI);  // x, y, radius, ArcStart, ArcEnd
            m.closePath();
            m.strokeStyle = m.fillStyle = color;
            fill ? m.fill() : m.stroke()
        };

        // Draw a line between two atoms
        function drawLineBetweenAtoms(ax, ay, bx, by, color) {
            m.beginPath();
            m.moveTo(ax, ay);
            m.lineTo(bx, by);
            m.closePath();
            m.strokeStyle = color;
            m.stroke();
        };

        // [position-x, position-y, radius, color]
        //    /* tmp accumulators: */
        //  {count, accum-x, accum-y, accum-d^2, accum-color}]
        let clusters = [];
        function newCluster() {
            return [randomX(), randomY(), maxRadius, 'white'];
        }
        function addNewClusters(num_clusters) {
            if (clusters.length < num_clusters / 2) {
                while (clusters.length < num_clusters) clusters.push(newCluster());
            }
        }
        function findNearestCluster(x, y) {
            let best = -1;
            let best_d2 = 1.e38;
            for (let i = 0; i < clusters.length; ++i) {
                const c = clusters[i];
                const dx = c[0] - x;
                const dy = c[1] - y;
                const d2 = dx * dx + dy * dy;
                if (d2 < best_d2) {
                    best = i;
                    best_d2 = d2;
                }
            }
            return [best, best_d2];
        }
        function moveClusters(accums) {
            let max_d = 0.;   // record max cluster displacement
            for (let i = 0; i < clusters.length; ++i) {
                let c = clusters[i];
                const a = accums[i];
                if (a[0] > minClusterSize) {
                    const norm = 1. / a[0];
                    const new_x = a[1] * norm;
                    const new_y = a[2] * norm;
                    max_d = Math.max(max_d, Math.abs(c[0] - new_x), Math.abs(c[1 ] - new_y));
                    c[0] = new_x;
                    c[1] = new_y;
                }
            }
            return max_d;
        }
        function finalizeClusters(accums) {
            for (let i = 0; i < clusters.length; ++i) {
                let c = clusters[i];
                const a = accums[i];
                if (a[0] > minClusterSize) {
                    const norm = 1. / a[0];
                    const new_r = 1.10 * Math.sqrt(a[3] * norm);  // with 10% extra room
                    c[2] = 0.95 * c[2] + 0.05 * new_r;  // exponential smoothing
                    // 'average' color
                    c[3] = settings.colors[Math.floor(a[4] * norm + .5)];
                } else {
                    c[2] = 0.;   // disable the weak cluster
                }
            }
            // note: if half of the particles are not within the average
            // radius of the cluster, we should probably split it in two
            // along the main axis!
        }
        function trackClusters() {
            addNewClusters(maxClusters);
            let accums = [];
            for (const c of clusters) accums.push([0, 0., 0., 0., 0]);
            const maxKMeanPasses = 10;
            for (let pass = maxKMeanPasses; pass >= 0; --pass) {
                for (let a of accums) a = [0, 0., 0., 0., 0];
                for (const c of atoms) {
                    const [best, best_d2] = findNearestCluster(c[0], c[1]);
                    if (best >= 0 && best_d2 < maxRadius * maxRadius) {
                        accums[best][0] += 1;
                        accums[best][1] += c[0];
                        accums[best][2] += c[1];
                        accums[best][3] += best_d2;
                        accums[best][4] += c[4];
                    }
                }
                const max_d = moveClusters(accums);
                if (max_d < 1.) break;
            }
            finalizeClusters(accums);
        }
        function drawClusters() {
            let i = 0;
            while (i < clusters.length) {
                let c = clusters[i];
                if (c[2] > 0.) {
                    drawCircle(c[0], c[1], c[3], c[2], false);
                    ++i;
                } else {
                    // remove cluster by swapping with last
                    const last = clusters.pop();
                    if (i < clusters.length) clusters[i] = last;
                }
            }
        }

        // Canvas Dimensions
        updateCanvasDimensions()


        // Params for click-based pulse event
        var pulse = 0;
        var pulse_x = 0,
            pulse_y = 0;

        var exploration_timer = 0;
        function exploreParameters() {
            if (exploration_timer <= 0) {
                let c1 = settings.colors[Math.floor(mulberry32() * settings.numColors)];
                if (mulberry32() >= 0.2) {  // 80% of the time, we change the strength
                  let c2 = settings.colors[Math.floor(mulberry32() * settings.numColors)];
                  let new_strength = mulberry32();
                  // for better results, we force opposite-signed values
                  if (settings.rules[c1][c2] > 0) new_strength = -new_strength;
                  settings.rules[c1][c2] = new_strength;
                } else {  // ...otherwise, the radius
                  settings.radii[c1] = 1 + Math.floor(mulberry32() * maxRadius);
                }
                flattenRules();
                exploration_timer = settings.explorePeriod;
            }
            exploration_timer -= 1;
        }

        var total_v; // global velocity as a estimate of on-screen activity

        // Apply Rules ( How atoms interact with each other )
        const applyRules = () => {
            total_v = 0.;
            // update velocity first
            for (const a of atoms) {
                let fx = 0;
                let fy = 0;
                const idx = a[4] * settings.numColors;
                const r2 = settings.radii2Array[a[4]]
                for (const b of atoms) {
                    const g = settings.rulesArray[idx + b[4]];
                    const dx = a[0] - b[0];
                    const dy = a[1] - b[1];
                    if (dx !== 0 || dy !== 0) {
                        const d = dx * dx + dy * dy;
                        if (d < r2) {
                            const F = g / Math.sqrt(d);
                            fx += F * dx;
                            fy += F * dy;

                            // Draw lines between atoms that are effecting each other.
                            if (settings.drawings.lines) {
                                drawLineBetweenAtoms(a[0], a[1], b[0], b[1], settings.colors[b[4]]);
                            }
                        }
                    }
                }
                if (pulse != 0) {
                    const dx = a[0] - pulse_x;
                    const dy = a[1] - pulse_y;
                    const d = dx * dx + dy * dy;
                    if (d > 0) {
                        const F = 100. * pulse / (d * settings.time_scale);
                        fx += F * dx;
                        fy += F * dy;
                    }
                }
                if (settings.wallRepel > 0) {
                  const d = settings.wallRepel
                  const strength = 0.1
                  if (a[0] <                d) fx += (d -                a[0]) * strength
                  if (a[0] > canvas.width - d) fx += (canvas.width - d - a[0]) * strength
                  if (a[1] <                 d) fy += (d                 - a[1]) * strength
                  if (a[1] > canvas.height - d) fy += (canvas.height - d - a[1]) * strength
                }
                fy += settings.gravity;
                const vmix = (1. - settings.viscosity);
                a[2] = a[2] * vmix + fx * settings.time_scale;
                a[3] = a[3] * vmix + fy * settings.time_scale;
                // record typical activity, so that we can scale the
                // time_scale later accordingly
                total_v += Math.abs(a[2]);
                total_v += Math.abs(a[3]);
            }
            // update positions now
            for (const a of atoms) {
                a[0] += a[2]
                a[1] += a[3]

                // When Atoms touch or bypass canvas borders
                if (a[0] < 0) {
                    a[0] = -a[0];
                    a[2] *= -1;
                }
                if (a[0] >= canvas.width) {
                    a[0] = 2 * canvas.width - a[0];
                    a[2] *= -1;
                }
                if (a[1] < 0) {
                    a[1] = -a[1];
                    a[3] *= -1;
                }
                if (a[1] >= canvas.height) {
                    a[1] = 2 * canvas.height - a[1];
                    a[3] *= -1;
                }

            }
            total_v /= atoms.length;
        };


        // Generate Rules
        setNumberOfColors()
        randomRules()

        // Generate Atoms
        const atoms = []
        randomAtoms(settings.atoms.count, true)


        setupClicks()
        setupKeys()
        setupGUI()

        console.log('settings', settings)

        // Update Frames
        var lastT = Date.now();
        update();

        function update() {
            // Update Canvas Dimensions - if screen size changed
            updateCanvasDimensions()
            // Background color
            m.fillStyle = settings.drawings.background_color;
            m.fillRect(0, 0, canvas.width, canvas.height);
            // Appy Rules
            applyRules();
            // Draw Atoms
            for (const a of atoms) {
                if (settings.drawings.circle) {
                    drawCircle(a[0], a[1], settings.colors[a[4]], settings.atoms.radius);
                } else {
                    drawSquare(a[0], a[1], settings.colors[a[4]], settings.atoms.radius);
                }
            }
            if (settings.drawings.clusters) {
                trackClusters();
                drawClusters();
            }

            updateParams();

            // const inRange = (a) => 0 <= a[0] && a[0] < canvas.width && 0 <= a[1] && a[1] < canvas.height
            // console.log('inRange', atoms.filter(inRange).length)

            requestAnimationFrame(update);
        };

        // post-frame stats and updates
        function updateParams() {
            // record FPS
            var curT = Date.now();
            if (curT > lastT) {
                const new_fps = 1000. / (curT - lastT);
                settings.fps = Math.round(settings.fps * 0.8 + new_fps * 0.2)
                lastT = curT;
            }

            // adapt time_scale based on activity
            if (total_v > 30. && settings.time_scale > 5.) settings.time_scale /= 1.1;
            if (settings.time_scale < 0.9) settings.time_scale *= 1.01;
            if (settings.time_scale > 1.1) settings.time_scale /= 1.01;

            if (pulse != 0) pulse -= (pulse > 0) ? 1 : -1;
            if (settings.explore) exploreParameters();
        }


        
        // Download DataURL
        function dataURL_downloader(dataURL, name = `particle_life_${settings.seed}`) {
            const hyperlink = document.createElement("a");
            // document.body.appendChild(hyperlink);
            hyperlink.download = name;
            hyperlink.target = '_blank';
            hyperlink.href = dataURL;
            hyperlink.click();
            hyperlink.remove();
        };


        // Recorde a video ----------------------------------
        // Stream
        const videoStream = canvas.captureStream();
        // Video Recorder
        const mediaRecorder = new MediaRecorder(videoStream);
        // temp chunks
        let chunks = [];
        // Store chanks
        mediaRecorder.ondataavailable = function (e) {
            chunks.push(e.data);
        };
        // Download video after recording is stopped
        mediaRecorder.onstop = function (e) {
            // Chunks ---> Blob
            const blob = new Blob(chunks, { 'type': 'video/mp4' });
            // Blob -----> DataURL
            const videoDataURL = URL.createObjectURL(blob);

            // Download video
            dataURL_downloader(videoDataURL);

            // Reset Chunks
            chunks = [];
        };
        
        // mediaRecorder.start(); // Start recording
        // mediaRecorder.stop(); // Stop recording
        // --------------------------------------------------
    </script>

</body>

</html>
